🏠

Chapter 10: Local State Strategies

When useState is enough (most of the time)

When useState is enough (most of the time)

Before we dive into complex state management patterns, let's establish a critical truth: most of your state management problems don't need complex solutions. The React community has a tendency to reach for sophisticated tools prematurely. We're going to start by understanding when the simplest toolβ€”useStateβ€”is exactly what you need.

Phase 1: The Reference Implementation

We're building a Task Board applicationβ€”a realistic, multi-feature UI that will serve as our testing ground for state management patterns throughout this chapter. This isn't a toy example. It's the kind of component you'd build in a real application: multiple pieces of related state, user interactions, and enough complexity to expose the limitations of each approach.

Project Structure:

src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ TaskBoard.tsx          ← Our reference implementation
β”‚   β”œβ”€β”€ TaskList.tsx
β”‚   β”œβ”€β”€ TaskItem.tsx
β”‚   β”œβ”€β”€ TaskForm.tsx
β”‚   └── FilterBar.tsx
β”œβ”€β”€ types/
β”‚   └── task.ts
└── App.tsx

Let's start with the domain model:

// src/types/task.ts
export type TaskStatus = 'todo' | 'in-progress' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high';

export interface Task {
  id: string;
  title: string;
  description: string;
  status: TaskStatus;
  priority: TaskPriority;
  assignee: string;
  createdAt: Date;
  dueDate?: Date;
}

export interface TaskFilters {
  status: TaskStatus | 'all';
  priority: TaskPriority | 'all';
  assignee: string;
  searchQuery: string;
}

Now, our initial implementation using only useState:

// src/components/TaskBoard.tsx - Version 1: Pure useState
import { useState } from 'react';
import { Task, TaskFilters, TaskStatus, TaskPriority } from '../types/task';
import { TaskList } from './TaskList';
import { TaskForm } from './TaskForm';
import { FilterBar } from './FilterBar';

export function TaskBoard() {
  // State: Each piece of data gets its own useState
  const [tasks, setTasks] = useState<Task[]>([
    {
      id: '1',
      title: 'Implement user authentication',
      description: 'Add JWT-based auth flow',
      status: 'in-progress',
      priority: 'high',
      assignee: 'Alice',
      createdAt: new Date('2024-01-15'),
      dueDate: new Date('2024-02-01'),
    },
    {
      id: '2',
      title: 'Write API documentation',
      description: 'Document all REST endpoints',
      status: 'todo',
      priority: 'medium',
      assignee: 'Bob',
      createdAt: new Date('2024-01-16'),
    },
    {
      id: '3',
      title: 'Fix mobile responsive issues',
      description: 'Dashboard breaks on small screens',
      status: 'done',
      priority: 'high',
      assignee: 'Alice',
      createdAt: new Date('2024-01-10'),
      dueDate: new Date('2024-01-20'),
    },
  ]);

  const [filters, setFilters] = useState<TaskFilters>({
    status: 'all',
    priority: 'all',
    assignee: '',
    searchQuery: '',
  });

  const [isFormOpen, setIsFormOpen] = useState(false);
  const [editingTask, setEditingTask] = useState<Task | null>(null);

  // Derived state: filtered tasks
  const filteredTasks = tasks.filter((task) => {
    if (filters.status !== 'all' && task.status !== filters.status) {
      return false;
    }
    if (filters.priority !== 'all' && task.priority !== filters.priority) {
      return false;
    }
    if (filters.assignee && task.assignee !== filters.assignee) {
      return false;
    }
    if (filters.searchQuery) {
      const query = filters.searchQuery.toLowerCase();
      return (
        task.title.toLowerCase().includes(query) ||
        task.description.toLowerCase().includes(query)
      );
    }
    return true;
  });

  // Actions: Functions that modify state
  const addTask = (taskData: Omit<Task, 'id' | 'createdAt'>) => {
    const newTask: Task = {
      ...taskData,
      id: crypto.randomUUID(),
      createdAt: new Date(),
    };
    setTasks((prev) => [...prev, newTask]);
    setIsFormOpen(false);
  };

  const updateTask = (taskId: string, updates: Partial<Task>) => {
    setTasks((prev) =>
      prev.map((task) =>
        task.id === taskId ? { ...task, ...updates } : task
      )
    );
    setEditingTask(null);
    setIsFormOpen(false);
  };

  const deleteTask = (taskId: string) => {
    setTasks((prev) => prev.filter((task) => task.id !== taskId));
  };

  const updateTaskStatus = (taskId: string, status: TaskStatus) => {
    setTasks((prev) =>
      prev.map((task) =>
        task.id === taskId ? { ...task, status } : task
      )
    );
  };

  const updateFilters = (newFilters: Partial<TaskFilters>) => {
    setFilters((prev) => ({ ...prev, ...newFilters }));
  };

  const clearFilters = () => {
    setFilters({
      status: 'all',
      priority: 'all',
      assignee: '',
      searchQuery: '',
    });
  };

  const openEditForm = (task: Task) => {
    setEditingTask(task);
    setIsFormOpen(true);
  };

  const closeForm = () => {
    setIsFormOpen(false);
    setEditingTask(null);
  };

  return (
    <div className="task-board">
      <header className="board-header">
        <h1>Task Board</h1>
        <button onClick={() => setIsFormOpen(true)}>
          Add Task
        </button>
      </header>

      <FilterBar
        filters={filters}
        onFilterChange={updateFilters}
        onClearFilters={clearFilters}
        taskCount={filteredTasks.length}
        totalCount={tasks.length}
      />

      <TaskList
        tasks={filteredTasks}
        onStatusChange={updateTaskStatus}
        onEdit={openEditForm}
        onDelete={deleteTask}
      />

      {isFormOpen && (
        <TaskForm
          task={editingTask}
          onSubmit={editingTask ? 
            (data) => updateTask(editingTask.id, data) : 
            addTask
          }
          onCancel={closeForm}
        />
      )}
    </div>
  );
}

The supporting components:

// src/components/FilterBar.tsx
import { TaskFilters, TaskStatus, TaskPriority } from '../types/task';

interface FilterBarProps {
  filters: TaskFilters;
  onFilterChange: (filters: Partial<TaskFilters>) => void;
  onClearFilters: () => void;
  taskCount: number;
  totalCount: number;
}

export function FilterBar({
  filters,
  onFilterChange,
  onClearFilters,
  taskCount,
  totalCount,
}: FilterBarProps) {
  return (
    <div className="filter-bar">
      <div className="filter-group">
        <label>Status:</label>
        <select
          value={filters.status}
          onChange={(e) => onFilterChange({ 
            status: e.target.value as TaskStatus | 'all' 
          })}
        >
          <option value="all">All</option>
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="done">Done</option>
        </select>
      </div>

      <div className="filter-group">
        <label>Priority:</label>
        <select
          value={filters.priority}
          onChange={(e) => onFilterChange({ 
            priority: e.target.value as TaskPriority | 'all' 
          })}
        >
          <option value="all">All</option>
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
        </select>
      </div>

      <div className="filter-group">
        <label>Search:</label>
        <input
          type="text"
          value={filters.searchQuery}
          onChange={(e) => onFilterChange({ searchQuery: e.target.value })}
          placeholder="Search tasks..."
        />
      </div>

      <button onClick={onClearFilters}>Clear Filters</button>

      <div className="task-count">
        Showing {taskCount} of {totalCount} tasks
      </div>
    </div>
  );
}
// src/components/TaskList.tsx
import { Task, TaskStatus } from '../types/task';
import { TaskItem } from './TaskItem';

interface TaskListProps {
  tasks: Task[];
  onStatusChange: (taskId: string, status: TaskStatus) => void;
  onEdit: (task: Task) => void;
  onDelete: (taskId: string) => void;
}

export function TaskList({ 
  tasks, 
  onStatusChange, 
  onEdit, 
  onDelete 
}: TaskListProps) {
  if (tasks.length === 0) {
    return (
      <div className="empty-state">
        <p>No tasks found. Create one to get started!</p>
      </div>
    );
  }

  return (
    <div className="task-list">
      {tasks.map((task) => (
        <TaskItem
          key={task.id}
          task={task}
          onStatusChange={onStatusChange}
          onEdit={onEdit}
          onDelete={onDelete}
        />
      ))}
    </div>
  );
}
// src/components/TaskItem.tsx
import { Task, TaskStatus } from '../types/task';

interface TaskItemProps {
  task: Task;
  onStatusChange: (taskId: string, status: TaskStatus) => void;
  onEdit: (task: Task) => void;
  onDelete: (taskId: string) => void;
}

export function TaskItem({ 
  task, 
  onStatusChange, 
  onEdit, 
  onDelete 
}: TaskItemProps) {
  const priorityColors = {
    low: '#4caf50',
    medium: '#ff9800',
    high: '#f44336',
  };

  return (
    <div className="task-item">
      <div className="task-header">
        <h3>{task.title}</h3>
        <span 
          className="priority-badge"
          style={{ backgroundColor: priorityColors[task.priority] }}
        >
          {task.priority}
        </span>
      </div>

      <p className="task-description">{task.description}</p>

      <div className="task-meta">
        <span>Assignee: {task.assignee}</span>
        {task.dueDate && (
          <span>Due: {task.dueDate.toLocaleDateString()}</span>
        )}
      </div>

      <div className="task-actions">
        <select
          value={task.status}
          onChange={(e) => onStatusChange(task.id, e.target.value as TaskStatus)}
        >
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="done">Done</option>
        </select>

        <button onClick={() => onEdit(task)}>Edit</button>
        <button onClick={() => onDelete(task.id)}>Delete</button>
      </div>
    </div>
  );
}
// src/components/TaskForm.tsx
import { useState } from 'react';
import { Task, TaskStatus, TaskPriority } from '../types/task';

interface TaskFormProps {
  task?: Task | null;
  onSubmit: (taskData: Omit<Task, 'id' | 'createdAt'>) => void;
  onCancel: () => void;
}

export function TaskForm({ task, onSubmit, onCancel }: TaskFormProps) {
  const [formData, setFormData] = useState({
    title: task?.title || '',
    description: task?.description || '',
    status: task?.status || 'todo' as TaskStatus,
    priority: task?.priority || 'medium' as TaskPriority,
    assignee: task?.assignee || '',
    dueDate: task?.dueDate?.toISOString().split('T')[0] || '',
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSubmit({
      ...formData,
      dueDate: formData.dueDate ? new Date(formData.dueDate) : undefined,
    });
  };

  return (
    <div className="modal-overlay">
      <form className="task-form" onSubmit={handleSubmit}>
        <h2>{task ? 'Edit Task' : 'New Task'}</h2>

        <label>
          Title:
          <input
            type="text"
            value={formData.title}
            onChange={(e) => setFormData({ ...formData, title: e.target.value })}
            required
          />
        </label>

        <label>
          Description:
          <textarea
            value={formData.description}
            onChange={(e) => setFormData({ ...formData, description: e.target.value })}
            required
          />
        </label>

        <label>
          Status:
          <select
            value={formData.status}
            onChange={(e) => setFormData({ 
              ...formData, 
              status: e.target.value as TaskStatus 
            })}
          >
            <option value="todo">To Do</option>
            <option value="in-progress">In Progress</option>
            <option value="done">Done</option>
          </select>
        </label>

        <label>
          Priority:
          <select
            value={formData.priority}
            onChange={(e) => setFormData({ 
              ...formData, 
              priority: e.target.value as TaskPriority 
            })}
          >
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
        </label>

        <label>
          Assignee:
          <input
            type="text"
            value={formData.assignee}
            onChange={(e) => setFormData({ ...formData, assignee: e.target.value })}
            required
          />
        </label>

        <label>
          Due Date:
          <input
            type="date"
            value={formData.dueDate}
            onChange={(e) => setFormData({ ...formData, dueDate: e.target.value })}
          />
        </label>

        <div className="form-actions">
          <button type="submit">
            {task ? 'Update' : 'Create'} Task
          </button>
          <button type="button" onClick={onCancel}>
            Cancel
          </button>
        </div>
      </form>
    </div>
  );
}

What Makes This Work

This implementation uses only useState, and it works perfectly well. Let's understand why:

1. Clear State Ownership

Each piece of state has a clear owner and purpose: - tasks: The source of truth for all task data - filters: Current filter selections - isFormOpen: Modal visibility - editingTask: Which task is being edited (if any)

2. Predictable State Updates

Every state change happens through a dedicated function: - addTask, updateTask, deleteTask for task mutations - updateFilters, clearFilters for filter changes - openEditForm, closeForm for UI state

3. Derived State is Computed, Not Stored

Notice filteredTasks is not in stateβ€”it's computed from tasks and filters. This is crucial. Storing derived state creates synchronization problems.

4. Props Flow Downward

Data flows down through props. Child components receive: - Data they need to display - Callbacks to trigger state changes in the parent

This is the React mental model: state lives at the top, flows down through props, and changes bubble up through callbacks.

When useState is Enough

This pattern works well when:

βœ… State is localized: All related state lives in one component βœ… Updates are simple: Each action changes one or two pieces of state βœ… No complex interdependencies: State changes don't trigger cascading updates βœ… Component tree is shallow: Props don't need to drill through many levels

For our TaskBoard, all these conditions are true. The state is self-contained, updates are straightforward, and the component tree is only 2-3 levels deep.

The Real-World Test

Let's verify this works by running through realistic user interactions:

Scenario 1: Filtering tasks 1. User selects "Status: In Progress" 2. updateFilters is called with { status: 'in-progress' } 3. filters state updates 4. filteredTasks recomputes automatically 5. TaskList re-renders with filtered data

Scenario 2: Editing a task 1. User clicks "Edit" on a task 2. openEditForm is called with the task object 3. editingTask state updates to that task 4. isFormOpen state updates to true 5. TaskForm renders with pre-filled data 6. User submits changes 7. updateTask is called 8. tasks state updates with new data 9. editingTask resets to null 10. isFormOpen resets to false

Browser Console (with React DevTools):

[TaskBoard] Rendered
[FilterBar] Rendered
[TaskList] Rendered
[TaskItem] Rendered (3 times - one per task)

// After filtering:
[TaskBoard] Re-rendered (filters changed)
[FilterBar] Re-rendered (filters prop changed)
[TaskList] Re-rendered (tasks prop changed)
[TaskItem] Rendered (1 time - only in-progress task)

// After editing:
[TaskBoard] Re-rendered (editingTask changed)
[TaskForm] Rendered (new component mounted)
// ... user edits ...
[TaskBoard] Re-rendered (tasks changed)
[TaskList] Re-rendered (tasks prop changed)
[TaskItem] Re-rendered (task prop changed)
[TaskForm] Unmounted

Everything re-renders exactly when it should. No unnecessary updates. No stale data. No bugs.

The Simplicity Advantage

This approach has significant benefits:

1. Easy to understand: Any React developer can read this code and understand it immediately

2. Easy to debug: React DevTools shows exactly what state exists and when it changes

3. Easy to test: Each function is pure and testable in isolation

4. Easy to modify: Adding a new piece of state or a new action is straightforward

5. No external dependencies: No libraries to learn, no abstractions to understand

When to Stick with useState

Most components in most applications should use this pattern. Reach for more complex solutions only when you encounter specific problems that useState cannot solve efficiently.

Signs that useState is working: - Code is easy to read and modify - No performance issues - No prop drilling through more than 2-3 levels - State updates are predictable and debuggable - Team members understand the code without explanation

If all these are true, don't change anything. The best code is the simplest code that works.

useReducer for complex state logic

useReducer for complex state logic

Phase 2: When useState Shows Its Limits

Our TaskBoard works well, but let's add a realistic feature that will expose the limitations of multiple useState calls: undo/redo functionality.

Users want to undo accidental deletions or revert changes. This is a common requirement in task management applications. Let's try to implement it with our current approach.

The Failure: State Synchronization Hell

Here's what happens when we try to add undo/redo with useState:

// src/components/TaskBoard.tsx - Attempting undo/redo with useState
import { useState } from 'react';
import { Task, TaskFilters } from '../types/task';

interface HistoryEntry {
  tasks: Task[];
  filters: TaskFilters;
  isFormOpen: boolean;
  editingTask: Task | null;
}

export function TaskBoard() {
  const [tasks, setTasks] = useState<Task[]>([/* initial tasks */]);
  const [filters, setFilters] = useState<TaskFilters>({/* initial filters */});
  const [isFormOpen, setIsFormOpen] = useState(false);
  const [editingTask, setEditingTask] = useState<Task | null>(null);

  // Undo/redo state
  const [history, setHistory] = useState<HistoryEntry[]>([]);
  const [historyIndex, setHistoryIndex] = useState(-1);

  // Try to save state to history before each change
  const saveToHistory = () => {
    const entry: HistoryEntry = {
      tasks,
      filters,
      isFormOpen,
      editingTask,
    };

    // Remove any "future" history if we're not at the end
    const newHistory = history.slice(0, historyIndex + 1);
    setHistory([...newHistory, entry]);
    setHistoryIndex(newHistory.length);
  };

  const addTask = (taskData: Omit<Task, 'id' | 'createdAt'>) => {
    saveToHistory(); // Save before change

    const newTask: Task = {
      ...taskData,
      id: crypto.randomUUID(),
      createdAt: new Date(),
    };
    setTasks((prev) => [...prev, newTask]);
    setIsFormOpen(false);
  };

  const deleteTask = (taskId: string) => {
    saveToHistory(); // Save before change
    setTasks((prev) => prev.filter((task) => task.id !== taskId));
  };

  const undo = () => {
    if (historyIndex > 0) {
      const previousState = history[historyIndex - 1];

      // Now we need to restore ALL state
      setTasks(previousState.tasks);
      setFilters(previousState.filters);
      setIsFormOpen(previousState.isFormOpen);
      setEditingTask(previousState.editingTask);
      setHistoryIndex(historyIndex - 1);
    }
  };

  const redo = () => {
    if (historyIndex < history.length - 1) {
      const nextState = history[historyIndex + 1];

      // Restore ALL state again
      setTasks(nextState.tasks);
      setFilters(nextState.filters);
      setIsFormOpen(nextState.isFormOpen);
      setEditingTask(nextState.editingTask);
      setHistoryIndex(historyIndex + 1);
    }
  };

  // ... rest of component
}

Let's run this and see what happens:

Browser Behavior: 1. User adds a task 2. User deletes a task 3. User clicks "Undo" 4. The deleted task reappears βœ“ 5. User clicks "Undo" again 6. The added task disappears βœ“ 7. User changes a filter 8. User clicks "Undo" 9. Nothing happens ❌

Browser Console:

Warning: Cannot update a component while rendering a different component.
  at TaskBoard (TaskBoard.tsx:45)

Warning: Maximum update depth exceeded. This can happen when a component 
calls setState inside useEffect, but useEffect either doesn't have a 
dependency array, or one of the dependencies changes on every render.
  at TaskBoard (TaskBoard.tsx:52)

React DevTools Evidence: - Component tree: TaskBoard highlighted in red (error state) - State inspection: - history: Array with 3 entries - historyIndex: 1 - tasks: Correct data - filters: Not in history - this is the problem - Render count: 847 renders in 2 seconds (infinite loop)

Diagnostic Analysis: Reading the Failure

What the user experiences: - Expected: Undo should revert the filter change - Actual: Undo does nothing, then the app freezes

What the console reveals: - Key indicator: "Maximum update depth exceeded" - Error location: Inside saveToHistory function - This means we're triggering infinite state updates

What DevTools shows: - Component state: history array keeps growing - Render behavior: Component re-renders continuously - The problem: saveToHistory reads current state, but that state is stale by the time it executes

Root cause identified:

When we call saveToHistory() before a state update, we're reading the current state values. But React batches state updates, so by the time saveToHistory executes, the state hasn't actually changed yet. We're saving the old state, then updating to the new state, then trying to save again, creating an infinite loop.

Additionally, we have four separate pieces of state that must stay synchronized: 1. tasks 2. filters 3. isFormOpen 4. editingTask

Every action must update the right combination of these, and every undo/redo must restore all of them atomically. With useState, there's no way to guarantee this happens correctly.

Why the current approach can't solve this:

useState is designed for independent pieces of state. When you have multiple related pieces of state that must change together, you need a different tool.

What we need:

A way to manage all related state as a single unit, with a clear definition of how each action transforms that state. We need useReducer.

Iteration 1: Refactoring to useReducer

useReducer is React's built-in solution for complex state logic. It's based on the reducer pattern from Redux, but simpler and built into React.

The mental model: - All state lives in a single object - Actions describe what happened - A reducer function defines how each action transforms the state - State updates are atomic and predictable

Let's refactor our TaskBoard:

// src/types/task.ts - Add action types
export type TaskStatus = 'todo' | 'in-progress' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high';

export interface Task {
  id: string;
  title: string;
  description: string;
  status: TaskStatus;
  priority: TaskPriority;
  assignee: string;
  createdAt: Date;
  dueDate?: Date;
}

export interface TaskFilters {
  status: TaskStatus | 'all';
  priority: TaskPriority | 'all';
  assignee: string;
  searchQuery: string;
}

// New: State shape
export interface TaskBoardState {
  tasks: Task[];
  filters: TaskFilters;
  isFormOpen: boolean;
  editingTask: Task | null;
  history: TaskBoardState[];
  historyIndex: number;
}

// New: Action types
export type TaskBoardAction =
  | { type: 'ADD_TASK'; payload: Omit<Task, 'id' | 'createdAt'> }
  | { type: 'UPDATE_TASK'; payload: { id: string; updates: Partial<Task> } }
  | { type: 'DELETE_TASK'; payload: string }
  | { type: 'UPDATE_TASK_STATUS'; payload: { id: string; status: TaskStatus } }
  | { type: 'UPDATE_FILTERS'; payload: Partial<TaskFilters> }
  | { type: 'CLEAR_FILTERS' }
  | { type: 'OPEN_FORM' }
  | { type: 'OPEN_EDIT_FORM'; payload: Task }
  | { type: 'CLOSE_FORM' }
  | { type: 'UNDO' }
  | { type: 'REDO' };

Now the reducer functionβ€”the heart of our state management:

// src/reducers/taskBoardReducer.ts
import { TaskBoardState, TaskBoardAction, Task } from '../types/task';

// Helper: Save current state to history before making changes
function saveToHistory(state: TaskBoardState): TaskBoardState {
  // Don't save history entries to history (avoid nested history)
  const stateToSave: TaskBoardState = {
    ...state,
    history: [],
    historyIndex: -1,
  };

  // Remove any "future" history if we're not at the end
  const newHistory = state.history.slice(0, state.historyIndex + 1);

  return {
    ...state,
    history: [...newHistory, stateToSave],
    historyIndex: newHistory.length,
  };
}

export function taskBoardReducer(
  state: TaskBoardState,
  action: TaskBoardAction
): TaskBoardState {
  switch (action.type) {
    case 'ADD_TASK': {
      const stateWithHistory = saveToHistory(state);
      const newTask: Task = {
        ...action.payload,
        id: crypto.randomUUID(),
        createdAt: new Date(),
      };

      return {
        ...stateWithHistory,
        tasks: [...stateWithHistory.tasks, newTask],
        isFormOpen: false,
      };
    }

    case 'UPDATE_TASK': {
      const stateWithHistory = saveToHistory(state);
      return {
        ...stateWithHistory,
        tasks: stateWithHistory.tasks.map((task) =>
          task.id === action.payload.id
            ? { ...task, ...action.payload.updates }
            : task
        ),
        editingTask: null,
        isFormOpen: false,
      };
    }

    case 'DELETE_TASK': {
      const stateWithHistory = saveToHistory(state);
      return {
        ...stateWithHistory,
        tasks: stateWithHistory.tasks.filter(
          (task) => task.id !== action.payload
        ),
      };
    }

    case 'UPDATE_TASK_STATUS': {
      const stateWithHistory = saveToHistory(state);
      return {
        ...stateWithHistory,
        tasks: stateWithHistory.tasks.map((task) =>
          task.id === action.payload.id
            ? { ...task, status: action.payload.status }
            : task
        ),
      };
    }

    case 'UPDATE_FILTERS':
      return {
        ...state,
        filters: { ...state.filters, ...action.payload },
      };

    case 'CLEAR_FILTERS':
      return {
        ...state,
        filters: {
          status: 'all',
          priority: 'all',
          assignee: '',
          searchQuery: '',
        },
      };

    case 'OPEN_FORM':
      return {
        ...state,
        isFormOpen: true,
        editingTask: null,
      };

    case 'OPEN_EDIT_FORM':
      return {
        ...state,
        isFormOpen: true,
        editingTask: action.payload,
      };

    case 'CLOSE_FORM':
      return {
        ...state,
        isFormOpen: false,
        editingTask: null,
      };

    case 'UNDO': {
      if (state.historyIndex > 0) {
        const previousState = state.history[state.historyIndex - 1];
        return {
          ...previousState,
          history: state.history,
          historyIndex: state.historyIndex - 1,
        };
      }
      return state;
    }

    case 'REDO': {
      if (state.historyIndex < state.history.length - 1) {
        const nextState = state.history[state.historyIndex + 1];
        return {
          ...nextState,
          history: state.history,
          historyIndex: state.historyIndex + 1,
        };
      }
      return state;
    }

    default:
      return state;
  }
}

Now the component becomes much simpler:

// src/components/TaskBoard.tsx - Version 2: Using useReducer
import { useReducer, useMemo } from 'react';
import { TaskBoardState, TaskStatus } from '../types/task';
import { taskBoardReducer } from '../reducers/taskBoardReducer';
import { TaskList } from './TaskList';
import { TaskForm } from './TaskForm';
import { FilterBar } from './FilterBar';

const initialState: TaskBoardState = {
  tasks: [
    {
      id: '1',
      title: 'Implement user authentication',
      description: 'Add JWT-based auth flow',
      status: 'in-progress',
      priority: 'high',
      assignee: 'Alice',
      createdAt: new Date('2024-01-15'),
      dueDate: new Date('2024-02-01'),
    },
    {
      id: '2',
      title: 'Write API documentation',
      description: 'Document all REST endpoints',
      status: 'todo',
      priority: 'medium',
      assignee: 'Bob',
      createdAt: new Date('2024-01-16'),
    },
    {
      id: '3',
      title: 'Fix mobile responsive issues',
      description: 'Dashboard breaks on small screens',
      status: 'done',
      priority: 'high',
      assignee: 'Alice',
      createdAt: new Date('2024-01-10'),
      dueDate: new Date('2024-01-20'),
    },
  ],
  filters: {
    status: 'all',
    priority: 'all',
    assignee: '',
    searchQuery: '',
  },
  isFormOpen: false,
  editingTask: null,
  history: [],
  historyIndex: -1,
};

export function TaskBoard() {
  const [state, dispatch] = useReducer(taskBoardReducer, initialState);

  // Derived state: filtered tasks
  const filteredTasks = useMemo(() => {
    return state.tasks.filter((task) => {
      if (state.filters.status !== 'all' && task.status !== state.filters.status) {
        return false;
      }
      if (state.filters.priority !== 'all' && task.priority !== state.filters.priority) {
        return false;
      }
      if (state.filters.assignee && task.assignee !== state.filters.assignee) {
        return false;
      }
      if (state.filters.searchQuery) {
        const query = state.filters.searchQuery.toLowerCase();
        return (
          task.title.toLowerCase().includes(query) ||
          task.description.toLowerCase().includes(query)
        );
      }
      return true;
    });
  }, [state.tasks, state.filters]);

  // Keyboard shortcuts for undo/redo
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
        e.preventDefault();
        if (e.shiftKey) {
          dispatch({ type: 'REDO' });
        } else {
          dispatch({ type: 'UNDO' });
        }
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, []);

  return (
    <div className="task-board">
      <header className="board-header">
        <h1>Task Board</h1>
        <div className="header-actions">
          <button onClick={() => dispatch({ type: 'OPEN_FORM' })}>
            Add Task
          </button>
          <button
            onClick={() => dispatch({ type: 'UNDO' })}
            disabled={state.historyIndex <= 0}
          >
            Undo (⌘Z)
          </button>
          <button
            onClick={() => dispatch({ type: 'REDO' })}
            disabled={state.historyIndex >= state.history.length - 1}
          >
            Redo (βŒ˜β‡§Z)
          </button>
        </div>
      </header>

      <FilterBar
        filters={state.filters}
        onFilterChange={(filters) =>
          dispatch({ type: 'UPDATE_FILTERS', payload: filters })
        }
        onClearFilters={() => dispatch({ type: 'CLEAR_FILTERS' })}
        taskCount={filteredTasks.length}
        totalCount={state.tasks.length}
      />

      <TaskList
        tasks={filteredTasks}
        onStatusChange={(id, status) =>
          dispatch({ type: 'UPDATE_TASK_STATUS', payload: { id, status } })
        }
        onEdit={(task) =>
          dispatch({ type: 'OPEN_EDIT_FORM', payload: task })
        }
        onDelete={(id) =>
          dispatch({ type: 'DELETE_TASK', payload: id })
        }
      />

      {state.isFormOpen && (
        <TaskForm
          task={state.editingTask}
          onSubmit={(data) =>
            state.editingTask
              ? dispatch({
                  type: 'UPDATE_TASK',
                  payload: { id: state.editingTask.id, updates: data },
                })
              : dispatch({ type: 'ADD_TASK', payload: data })
          }
          onCancel={() => dispatch({ type: 'CLOSE_FORM' })}
        />
      )}
    </div>
  );
}

Verification: Does It Work?

Let's run through the same scenario that failed before:

User Actions: 1. Add a task β†’ "Write tests" 2. Delete a task β†’ "Fix mobile responsive issues" 3. Change filter β†’ Status: "In Progress" 4. Press ⌘Z (Undo) 5. Press ⌘Z (Undo) 6. Press ⌘Z (Undo)

Browser Console:

[TaskBoard] Rendered
[TaskBoard] Action dispatched: ADD_TASK
[TaskBoard] State updated, re-rendering
[TaskBoard] Action dispatched: DELETE_TASK
[TaskBoard] State updated, re-rendering
[TaskBoard] Action dispatched: UPDATE_FILTERS
[TaskBoard] State updated, re-rendering
[TaskBoard] Action dispatched: UNDO
[TaskBoard] State updated, re-rendering (filter reverted)
[TaskBoard] Action dispatched: UNDO
[TaskBoard] State updated, re-rendering (deleted task restored)
[TaskBoard] Action dispatched: UNDO
[TaskBoard] State updated, re-rendering (added task removed)

React DevTools Evidence: - Component tree: TaskBoard rendering normally - State inspection: - state.tasks: Array with 3 tasks (back to original) - state.filters: Back to default values - state.history: Array with 3 entries - state.historyIndex: 0 (at the beginning) - Render count: 7 renders total (1 initial + 6 actions) - No infinite loops, no errors

Expected vs. Actual: - βœ… Filter change undone correctly - βœ… Deleted task restored - βœ… Added task removed - βœ… All state synchronized - βœ… No performance issues - βœ… Keyboard shortcuts work

What Changed and Why It Works

Before (useState):

const [tasks, setTasks] = useState<Task[]>([]);
const [filters, setFilters] = useState<TaskFilters>({});
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);

// Problem: Four separate pieces of state
// Problem: No guarantee they update together
// Problem: History must track all four separately

After (useReducer):

const [state, dispatch] = useReducer(taskBoardReducer, initialState);

// Solution: One state object
// Solution: All updates are atomic
// Solution: History is built into the state

The useReducer Mental Model

Think of useReducer as a state machine:

  1. State: The current snapshot of your application
  2. Actions: Events that describe what happened
  3. Reducer: A pure function that computes the next state
  4. Dispatch: The function that sends actions to the reducer
Current State + Action β†’ Reducer β†’ Next State

The reducer is pure: given the same state and action, it always returns the same next state. This makes it: - Predictable - Testable - Debuggable - Time-travel-able (undo/redo)

When to Use useReducer

Use useReducer when you have:

βœ… Multiple related pieces of state that must update together βœ… Complex update logic with many conditional branches βœ… State transitions that depend on previous state βœ… Need for undo/redo or time-travel debugging βœ… Actions that need to be logged or replayed

Don't use useReducer when:

❌ State is simple and independent ❌ Updates are straightforward (just setting a value) ❌ You're adding complexity without solving a real problem

Common Failure Modes with useReducer

Symptom: State updates but component doesn't re-render

Root cause: Mutating state instead of returning a new object

Wrong:

case 'ADD_TASK':
  state.tasks.push(newTask); // Mutation!
  return state; // Same reference, React won't re-render

Right:

case 'ADD_TASK':
  return {
    ...state,
    tasks: [...state.tasks, newTask], // New array
  };

Symptom: Reducer becomes a giant switch statement

Root cause: Too many actions, not enough abstraction

Solution: Split into multiple reducers or extract helper functions

// Instead of one giant reducer:
function taskBoardReducer(state, action) {
  switch (action.type) {
    case 'ADD_TASK': /* ... */
    case 'UPDATE_TASK': /* ... */
    case 'DELETE_TASK': /* ... */
    // ... 20 more cases
  }
}

// Split into focused reducers:
function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'ADD_TASK': /* ... */
    case 'UPDATE_TASK': /* ... */
    case 'DELETE_TASK': /* ... */
  }
}

function filtersReducer(filters, action) {
  switch (action.type) {
    case 'UPDATE_FILTERS': /* ... */
    case 'CLEAR_FILTERS': /* ... */
  }
}

function taskBoardReducer(state, action) {
  return {
    tasks: tasksReducer(state.tasks, action),
    filters: filtersReducer(state.filters, action),
    // ...
  };
}

Testing useReducer

Reducers are pure functions, making them trivial to test:

// src/reducers/taskBoardReducer.test.ts
import { describe, it, expect } from 'vitest';
import { taskBoardReducer } from './taskBoardReducer';
import { TaskBoardState } from '../types/task';

describe('taskBoardReducer', () => {
  const initialState: TaskBoardState = {
    tasks: [],
    filters: {
      status: 'all',
      priority: 'all',
      assignee: '',
      searchQuery: '',
    },
    isFormOpen: false,
    editingTask: null,
    history: [],
    historyIndex: -1,
  };

  it('adds a task', () => {
    const action = {
      type: 'ADD_TASK' as const,
      payload: {
        title: 'Test task',
        description: 'Test description',
        status: 'todo' as const,
        priority: 'medium' as const,
        assignee: 'Alice',
      },
    };

    const nextState = taskBoardReducer(initialState, action);

    expect(nextState.tasks).toHaveLength(1);
    expect(nextState.tasks[0].title).toBe('Test task');
    expect(nextState.isFormOpen).toBe(false);
    expect(nextState.history).toHaveLength(1); // History saved
  });

  it('deletes a task', () => {
    const stateWithTask: TaskBoardState = {
      ...initialState,
      tasks: [
        {
          id: '1',
          title: 'Task to delete',
          description: 'Will be deleted',
          status: 'todo',
          priority: 'low',
          assignee: 'Bob',
          createdAt: new Date(),
        },
      ],
    };

    const action = {
      type: 'DELETE_TASK' as const,
      payload: '1',
    };

    const nextState = taskBoardReducer(stateWithTask, action);

    expect(nextState.tasks).toHaveLength(0);
    expect(nextState.history).toHaveLength(1); // History saved
  });

  it('supports undo', () => {
    // Start with a task
    const stateWithTask: TaskBoardState = {
      ...initialState,
      tasks: [
        {
          id: '1',
          title: 'Original task',
          description: 'Original',
          status: 'todo',
          priority: 'low',
          assignee: 'Alice',
          createdAt: new Date(),
        },
      ],
    };

    // Delete the task
    const stateAfterDelete = taskBoardReducer(stateWithTask, {
      type: 'DELETE_TASK',
      payload: '1',
    });

    expect(stateAfterDelete.tasks).toHaveLength(0);

    // Undo the deletion
    const stateAfterUndo = taskBoardReducer(stateAfterDelete, {
      type: 'UNDO',
    });

    expect(stateAfterUndo.tasks).toHaveLength(1);
    expect(stateAfterUndo.tasks[0].title).toBe('Original task');
  });

  it('does nothing when undoing with no history', () => {
    const action = { type: 'UNDO' as const };
    const nextState = taskBoardReducer(initialState, action);

    expect(nextState).toBe(initialState); // Same reference
  });
});

The Reducer Pattern: Lessons Learned

1. Actions are data, not functions

Actions describe what happened, not how to handle it. The reducer decides how to handle it.

2. Reducers are pure

No side effects. No API calls. No randomness. Just: (state, action) => nextState.

3. State is immutable

Always return a new state object. Never mutate the existing state.

4. One source of truth

All related state lives in one object. No synchronization problems.

5. Time-travel debugging

Because every state change is explicit and reversible, you can implement undo/redo, replay actions, or debug by stepping through state changes.

When to Apply This Solution

What it optimizes for: - State consistency - Complex update logic - Predictable state transitions - Testability - Debugging

What it sacrifices: - Initial setup complexity - More boilerplate code - Learning curve for team members unfamiliar with reducers

When to choose this approach: - Multiple pieces of state that must stay synchronized - Complex state update logic with many conditions - Need for undo/redo or time-travel debugging - State transitions that depend on previous state - Team is comfortable with Redux-style patterns

When to avoid this approach: - Simple, independent state - Straightforward updates (just setting values) - Small components with minimal state - Team prefers simpler patterns

Code characteristics: - Setup: More initial code (types, reducer, actions) - Maintenance: Easier to modify (add new actions without touching component) - Performance: Same as useState (React optimizes both equally) - Testing: Much easier (pure functions)

The key insight: useReducer doesn't make your code faster or more performant. It makes it more maintainable and predictable when state logic becomes complex.

Context API: use sparingly

Context API: use sparingly

Phase 3: The Prop Drilling Problem

Our TaskBoard works well, but it's still a single component. In a real application, we'd want to split it into multiple pages and share state across them. Let's add a realistic feature: a separate analytics dashboard that shows task statistics.

New Project Structure:

src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ TaskBoard.tsx
β”‚   β”œβ”€β”€ TaskList.tsx
β”‚   β”œβ”€β”€ TaskItem.tsx
β”‚   β”œβ”€β”€ TaskForm.tsx
β”‚   β”œβ”€β”€ FilterBar.tsx
β”‚   β”œβ”€β”€ AnalyticsDashboard.tsx  ← New
β”‚   └── TaskStats.tsx            ← New
β”œβ”€β”€ pages/
β”‚   β”œβ”€β”€ TasksPage.tsx            ← New
β”‚   └── AnalyticsPage.tsx        ← New
β”œβ”€β”€ App.tsx
└── types/
    └── task.ts

The Failure: Prop Drilling Through Five Levels

Let's try to share our task state across pages using props:

// src/App.tsx - Attempting to share state via props
import { useReducer } from 'react';
import { TaskBoardState } from './types/task';
import { taskBoardReducer } from './reducers/taskBoardReducer';
import { TasksPage } from './pages/TasksPage';
import { AnalyticsPage } from './pages/AnalyticsPage';

const initialState: TaskBoardState = {
  /* ... initial state ... */
};

export function App() {
  const [state, dispatch] = useReducer(taskBoardReducer, initialState);
  const [currentPage, setCurrentPage] = useState<'tasks' | 'analytics'>('tasks');

  return (
    <div className="app">
      <nav>
        <button onClick={() => setCurrentPage('tasks')}>Tasks</button>
        <button onClick={() => setCurrentPage('analytics')}>Analytics</button>
      </nav>

      {currentPage === 'tasks' ? (
        <TasksPage state={state} dispatch={dispatch} />
      ) : (
        <AnalyticsPage state={state} dispatch={dispatch} />
      )}
    </div>
  );
}
// src/pages/TasksPage.tsx
import { TaskBoardState, TaskBoardAction } from '../types/task';
import { TaskBoard } from '../components/TaskBoard';

interface TasksPageProps {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>;
}

export function TasksPage({ state, dispatch }: TasksPageProps) {
  return (
    <div className="tasks-page">
      <TaskBoard state={state} dispatch={dispatch} />
    </div>
  );
}
// src/components/TaskBoard.tsx - Now needs props
import { TaskBoardState, TaskBoardAction } from '../types/task';
import { TaskList } from './TaskList';
import { FilterBar } from './FilterBar';

interface TaskBoardProps {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>;
}

export function TaskBoard({ state, dispatch }: TaskBoardProps) {
  // Now we have to pass state and dispatch to every child
  return (
    <div className="task-board">
      <FilterBar
        filters={state.filters}
        onFilterChange={(filters) =>
          dispatch({ type: 'UPDATE_FILTERS', payload: filters })
        }
        // ... more props
      />

      <TaskList
        tasks={filteredTasks}
        dispatch={dispatch} // Pass dispatch down
        // ... more props
      />
    </div>
  );
}
// src/components/TaskList.tsx - Needs dispatch now
import { Task, TaskBoardAction } from '../types/task';
import { TaskItem } from './TaskItem';

interface TaskListProps {
  tasks: Task[];
  dispatch: React.Dispatch<TaskBoardAction>; // Added
}

export function TaskList({ tasks, dispatch }: TaskListProps) {
  return (
    <div className="task-list">
      {tasks.map((task) => (
        <TaskItem
          key={task.id}
          task={task}
          dispatch={dispatch} // Pass dispatch down again
        />
      ))}
    </div>
  );
}
// src/components/TaskItem.tsx - Finally uses dispatch
import { Task, TaskBoardAction } from '../types/task';

interface TaskItemProps {
  task: Task;
  dispatch: React.Dispatch<TaskBoardAction>; // Added
}

export function TaskItem({ task, dispatch }: TaskItemProps) {
  return (
    <div className="task-item">
      {/* ... task display ... */}
      <button
        onClick={() =>
          dispatch({ type: 'DELETE_TASK', payload: task.id })
        }
      >
        Delete
      </button>
    </div>
  );
}

Now let's add the analytics page:

// src/pages/AnalyticsPage.tsx
import { TaskBoardState, TaskBoardAction } from '../types/task';
import { AnalyticsDashboard } from '../components/AnalyticsDashboard';

interface AnalyticsPageProps {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>;
}

export function AnalyticsPage({ state, dispatch }: AnalyticsPageProps) {
  return (
    <div className="analytics-page">
      <h1>Task Analytics</h1>
      <AnalyticsDashboard state={state} dispatch={dispatch} />
    </div>
  );
}
// src/components/AnalyticsDashboard.tsx
import { TaskBoardState, TaskBoardAction } from '../types/task';
import { TaskStats } from './TaskStats';

interface AnalyticsDashboardProps {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>;
}

export function AnalyticsDashboard({ state, dispatch }: AnalyticsDashboardProps) {
  return (
    <div className="analytics-dashboard">
      <TaskStats state={state} dispatch={dispatch} />
      {/* More analytics components */}
    </div>
  );
}
// src/components/TaskStats.tsx
import { TaskBoardState, TaskBoardAction } from '../types/task';

interface TaskStatsProps {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>; // Doesn't even use this!
}

export function TaskStats({ state }: TaskStatsProps) {
  const totalTasks = state.tasks.length;
  const completedTasks = state.tasks.filter((t) => t.status === 'done').length;
  const inProgressTasks = state.tasks.filter((t) => t.status === 'in-progress').length;

  return (
    <div className="task-stats">
      <div className="stat">
        <h3>Total Tasks</h3>
        <p>{totalTasks}</p>
      </div>
      <div className="stat">
        <h3>Completed</h3>
        <p>{completedTasks}</p>
      </div>
      <div className="stat">
        <h3>In Progress</h3>
        <p>{inProgressTasks}</p>
      </div>
    </div>
  );
}

Diagnostic Analysis: Reading the Prop Drilling Problem

Browser Behavior: - App works correctly - Data flows from App β†’ Pages β†’ Components - But the code is becoming unmaintainable

React DevTools Evidence: - Component tree shows the prop chain: App └─ TasksPage (props: state, dispatch) └─ TaskBoard (props: state, dispatch) └─ TaskList (props: tasks, dispatch) └─ TaskItem (props: task, dispatch) - Every intermediate component passes props it doesn't use - TaskList doesn't use dispatch, but must pass it to TaskItem - AnalyticsDashboard doesn't use dispatch, but must pass it to TaskStats

Code smell indicators: 1. Props that tunnel through: Components receive props only to pass them down 2. Brittle refactoring: Moving a component requires updating all intermediate components 3. Type duplication: Every component needs the same prop types 4. Cognitive overhead: Hard to understand what each component actually needs

What the user experiences: - Expected: App works fine - Actual: App works fine, but developers suffer

Root cause identified:

We're using props for global state. Props are designed for parent-to-child communication, not for application-wide state. When state needs to be accessed by components at different levels of the tree, props become a burden.

Why the current approach can't solve this:

Props flow downward. To share state between distant components, you must lift state to a common ancestor and pass it down through every intermediate component. This creates coupling and maintenance problems.

What we need:

A way to make state available to any component that needs it, without passing it through every intermediate component. We need Context.

Iteration 2: Refactoring to Context

The Context API provides a way to share values between components without explicitly passing props through every level of the tree.

The mental model: - Create a Context to hold shared state - Provide the Context value at the top of your component tree - Consume the Context value in any descendant component

Let's refactor:

// src/contexts/TaskBoardContext.tsx
import { createContext, useContext, useReducer, ReactNode } from 'react';
import { TaskBoardState, TaskBoardAction } from '../types/task';
import { taskBoardReducer } from '../reducers/taskBoardReducer';

// Define the context value type
interface TaskBoardContextValue {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>;
}

// Create the context with undefined as default
// (we'll provide the real value in the Provider)
const TaskBoardContext = createContext<TaskBoardContextValue | undefined>(
  undefined
);

// Initial state
const initialState: TaskBoardState = {
  tasks: [
    {
      id: '1',
      title: 'Implement user authentication',
      description: 'Add JWT-based auth flow',
      status: 'in-progress',
      priority: 'high',
      assignee: 'Alice',
      createdAt: new Date('2024-01-15'),
      dueDate: new Date('2024-02-01'),
    },
    {
      id: '2',
      title: 'Write API documentation',
      description: 'Document all REST endpoints',
      status: 'todo',
      priority: 'medium',
      assignee: 'Bob',
      createdAt: new Date('2024-01-16'),
    },
    {
      id: '3',
      title: 'Fix mobile responsive issues',
      description: 'Dashboard breaks on small screens',
      status: 'done',
      priority: 'high',
      assignee: 'Alice',
      createdAt: new Date('2024-01-10'),
      dueDate: new Date('2024-01-20'),
    },
  ],
  filters: {
    status: 'all',
    priority: 'all',
    assignee: '',
    searchQuery: '',
  },
  isFormOpen: false,
  editingTask: null,
  history: [],
  historyIndex: -1,
};

// Provider component
interface TaskBoardProviderProps {
  children: ReactNode;
}

export function TaskBoardProvider({ children }: TaskBoardProviderProps) {
  const [state, dispatch] = useReducer(taskBoardReducer, initialState);

  const value: TaskBoardContextValue = {
    state,
    dispatch,
  };

  return (
    <TaskBoardContext.Provider value={value}>
      {children}
    </TaskBoardContext.Provider>
  );
}

// Custom hook to use the context
export function useTaskBoard() {
  const context = useContext(TaskBoardContext);

  if (context === undefined) {
    throw new Error('useTaskBoard must be used within a TaskBoardProvider');
  }

  return context;
}

Now update the App to use the Provider:

// src/App.tsx - Using Context Provider
import { useState } from 'react';
import { TaskBoardProvider } from './contexts/TaskBoardContext';
import { TasksPage } from './pages/TasksPage';
import { AnalyticsPage } from './pages/AnalyticsPage';

export function App() {
  const [currentPage, setCurrentPage] = useState<'tasks' | 'analytics'>('tasks');

  return (
    <TaskBoardProvider>
      <div className="app">
        <nav>
          <button onClick={() => setCurrentPage('tasks')}>Tasks</button>
          <button onClick={() => setCurrentPage('analytics')}>Analytics</button>
        </nav>

        {currentPage === 'tasks' ? <TasksPage /> : <AnalyticsPage />}
      </div>
    </TaskBoardProvider>
  );
}

Pages no longer need props:

// src/pages/TasksPage.tsx - No props needed
import { TaskBoard } from '../components/TaskBoard';

export function TasksPage() {
  return (
    <div className="tasks-page">
      <TaskBoard />
    </div>
  );
}
// src/pages/AnalyticsPage.tsx - No props needed
import { AnalyticsDashboard } from '../components/AnalyticsDashboard';

export function AnalyticsPage() {
  return (
    <div className="analytics-page">
      <h1>Task Analytics</h1>
      <AnalyticsDashboard />
    </div>
  );
}

Components consume context directly:

// src/components/TaskBoard.tsx - Using Context
import { useMemo } from 'react';
import { useTaskBoard } from '../contexts/TaskBoardContext';
import { TaskList } from './TaskList';
import { FilterBar } from './FilterBar';
import { TaskForm } from './TaskForm';

export function TaskBoard() {
  const { state, dispatch } = useTaskBoard(); // Get from context

  const filteredTasks = useMemo(() => {
    return state.tasks.filter((task) => {
      if (state.filters.status !== 'all' && task.status !== state.filters.status) {
        return false;
      }
      if (state.filters.priority !== 'all' && task.priority !== state.filters.priority) {
        return false;
      }
      if (state.filters.assignee && task.assignee !== state.filters.assignee) {
        return false;
      }
      if (state.filters.searchQuery) {
        const query = state.filters.searchQuery.toLowerCase();
        return (
          task.title.toLowerCase().includes(query) ||
          task.description.toLowerCase().includes(query)
        );
      }
      return true;
    });
  }, [state.tasks, state.filters]);

  return (
    <div className="task-board">
      <header className="board-header">
        <h1>Task Board</h1>
        <div className="header-actions">
          <button onClick={() => dispatch({ type: 'OPEN_FORM' })}>
            Add Task
          </button>
          <button
            onClick={() => dispatch({ type: 'UNDO' })}
            disabled={state.historyIndex <= 0}
          >
            Undo
          </button>
          <button
            onClick={() => dispatch({ type: 'REDO' })}
            disabled={state.historyIndex >= state.history.length - 1}
          >
            Redo
          </button>
        </div>
      </header>

      <FilterBar
        filters={state.filters}
        onFilterChange={(filters) =>
          dispatch({ type: 'UPDATE_FILTERS', payload: filters })
        }
        onClearFilters={() => dispatch({ type: 'CLEAR_FILTERS' })}
        taskCount={filteredTasks.length}
        totalCount={state.tasks.length}
      />

      <TaskList tasks={filteredTasks} />

      {state.isFormOpen && (
        <TaskForm
          task={state.editingTask}
          onSubmit={(data) =>
            state.editingTask
              ? dispatch({
                  type: 'UPDATE_TASK',
                  payload: { id: state.editingTask.id, updates: data },
                })
              : dispatch({ type: 'ADD_TASK', payload: data })
          }
          onCancel={() => dispatch({ type: 'CLOSE_FORM' })}
        />
      )}
    </div>
  );
}
// src/components/TaskList.tsx - No dispatch prop needed
import { Task } from '../types/task';
import { TaskItem } from './TaskItem';

interface TaskListProps {
  tasks: Task[];
}

export function TaskList({ tasks }: TaskListProps) {
  if (tasks.length === 0) {
    return (
      <div className="empty-state">
        <p>No tasks found. Create one to get started!</p>
      </div>
    );
  }

  return (
    <div className="task-list">
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </div>
  );
}
// src/components/TaskItem.tsx - Gets dispatch from context
import { Task, TaskStatus } from '../types/task';
import { useTaskBoard } from '../contexts/TaskBoardContext';

interface TaskItemProps {
  task: Task;
}

export function TaskItem({ task }: TaskItemProps) {
  const { dispatch } = useTaskBoard(); // Get from context

  const priorityColors = {
    low: '#4caf50',
    medium: '#ff9800',
    high: '#f44336',
  };

  return (
    <div className="task-item">
      <div className="task-header">
        <h3>{task.title}</h3>
        <span
          className="priority-badge"
          style={{ backgroundColor: priorityColors[task.priority] }}
        >
          {task.priority}
        </span>
      </div>

      <p className="task-description">{task.description}</p>

      <div className="task-meta">
        <span>Assignee: {task.assignee}</span>
        {task.dueDate && (
          <span>Due: {task.dueDate.toLocaleDateString()}</span>
        )}
      </div>

      <div className="task-actions">
        <select
          value={task.status}
          onChange={(e) =>
            dispatch({
              type: 'UPDATE_TASK_STATUS',
              payload: { id: task.id, status: e.target.value as TaskStatus },
            })
          }
        >
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="done">Done</option>
        </select>

        <button
          onClick={() =>
            dispatch({ type: 'OPEN_EDIT_FORM', payload: task })
          }
        >
          Edit
        </button>
        <button
          onClick={() =>
            dispatch({ type: 'DELETE_TASK', payload: task.id })
          }
        >
          Delete
        </button>
      </div>
    </div>
  );
}
// src/components/TaskStats.tsx - Gets state from context
import { useTaskBoard } from '../contexts/TaskBoardContext';

export function TaskStats() {
  const { state } = useTaskBoard(); // Get from context

  const totalTasks = state.tasks.length;
  const completedTasks = state.tasks.filter((t) => t.status === 'done').length;
  const inProgressTasks = state.tasks.filter(
    (t) => t.status === 'in-progress'
  ).length;
  const todoTasks = state.tasks.filter((t) => t.status === 'todo').length;

  const completionRate =
    totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;

  return (
    <div className="task-stats">
      <div className="stat">
        <h3>Total Tasks</h3>
        <p className="stat-value">{totalTasks}</p>
      </div>
      <div className="stat">
        <h3>Completed</h3>
        <p className="stat-value">{completedTasks}</p>
      </div>
      <div className="stat">
        <h3>In Progress</h3>
        <p className="stat-value">{inProgressTasks}</p>
      </div>
      <div className="stat">
        <h3>To Do</h3>
        <p className="stat-value">{todoTasks}</p>
      </div>
      <div className="stat">
        <h3>Completion Rate</h3>
        <p className="stat-value">{completionRate}%</p>
      </div>
    </div>
  );
}

Verification: Does It Work?

Browser Console:

[App] Rendered
[TaskBoardProvider] Rendered
[TasksPage] Rendered
[TaskBoard] Rendered
[TaskList] Rendered
[TaskItem] Rendered (3 times)

// User switches to Analytics page:
[AnalyticsPage] Rendered
[AnalyticsDashboard] Rendered
[TaskStats] Rendered
// TaskStats has access to the same state!

// User deletes a task on Tasks page:
[TaskBoard] Action dispatched: DELETE_TASK
[TaskBoardProvider] State updated
[TaskBoard] Re-rendered
[TaskList] Re-rendered
[TaskItem] Re-rendered (2 times - one task removed)

// User switches back to Analytics page:
[AnalyticsPage] Re-rendered
[TaskStats] Re-rendered
// Stats automatically updated with new task count!

React DevTools Evidence: - Component tree: App └─ TaskBoardProvider (provides context) β”œβ”€ TasksPage β”‚ └─ TaskBoard (consumes context) β”‚ └─ TaskList β”‚ └─ TaskItem (consumes context) └─ AnalyticsPage └─ AnalyticsDashboard └─ TaskStats (consumes context) - Context value visible in DevTools - No prop drilling through intermediate components - State shared across both pages

Expected vs. Actual: - βœ… No prop drilling - βœ… Components access state directly - βœ… State shared across pages - βœ… Changes in one page reflect in another - βœ… Cleaner component interfaces

What Changed and Why It Works

Before (Props):

// Every component in the chain needs props
<App state={state} dispatch={dispatch}>
  <TasksPage state={state} dispatch={dispatch}>
    <TaskBoard state={state} dispatch={dispatch}>
      <TaskList dispatch={dispatch}>
        <TaskItem dispatch={dispatch} />

After (Context):

// Only Provider and consumers need to know about state
<TaskBoardProvider>
  <TasksPage>
    <TaskBoard> {/* uses context */}
      <TaskList>
        <TaskItem /> {/* uses context */}

The Context Mental Model

Think of Context as a wormhole through your component tree:

  1. Provider: Creates the wormhole entrance at the top
  2. Consumer: Opens the wormhole exit anywhere below
  3. Value: Travels through the wormhole instantly

No intermediate components need to know about the wormhole.

The Performance Problem: Why "Use Sparingly"

Context solves prop drilling, but it introduces a new problem: unnecessary re-renders.

Let's see the failure:

// src/components/TaskStats.tsx - Add console.log
import { useTaskBoard } from '../contexts/TaskBoardContext';

export function TaskStats() {
  console.log('[TaskStats] Rendering');
  const { state } = useTaskBoard();

  // ... rest of component
}
// src/components/FilterBar.tsx - Add console.log
import { TaskFilters } from '../types/task';
import { useTaskBoard } from '../contexts/TaskBoardContext';

export function FilterBar() {
  console.log('[FilterBar] Rendering');
  const { state, dispatch } = useTaskBoard();

  return (
    <div className="filter-bar">
      <select
        value={state.filters.status}
        onChange={(e) =>
          dispatch({
            type: 'UPDATE_FILTERS',
            payload: { status: e.target.value },
          })
        }
      >
        {/* ... options ... */}
      </select>
    </div>
  );
}

Now let's change a filter:

Browser Console:

// User changes filter from "All" to "In Progress"
[FilterBar] Rendering
[TaskBoard] Rendering
[TaskList] Rendering
[TaskItem] Rendering (1 time - filtered task)
[TaskStats] Rendering  ← Why is this rendering?

React DevTools Evidence: - Profiler shows TaskStats re-rendered - Reason: Context value changed - TaskStats is on a different page (not even visible!) - But it still re-renders because it consumes the context

Diagnostic Analysis: The Context Re-render Problem

What the user experiences: - Expected: Only visible components re-render - Actual: All context consumers re-render, even hidden ones

What DevTools shows: - Every component that calls useTaskBoard() re-renders - This happens even if they don't use the changed part of state - TaskStats only uses state.tasks, but re-renders when state.filters changes

Root cause identified:

When context value changes, every component that consumes that context re-renders. React doesn't know which parts of the context value each component uses, so it re-renders all of them.

This is fine for small apps, but becomes a performance problem as your app grows.

Why the current approach can't solve this:

Context is all-or-nothing. You can't subscribe to just part of the context value. If any part changes, all consumers re-render.

Iteration 3: Optimizing Context with Selectors

We can mitigate this with careful context design:

// src/contexts/TaskBoardContext.tsx - Optimized version
import {
  createContext,
  useContext,
  useReducer,
  useMemo,
  ReactNode,
} from 'react';
import { TaskBoardState, TaskBoardAction } from '../types/task';
import { taskBoardReducer } from '../reducers/taskBoardReducer';

interface TaskBoardContextValue {
  state: TaskBoardState;
  dispatch: React.Dispatch<TaskBoardAction>;
}

const TaskBoardContext = createContext<TaskBoardContextValue | undefined>(
  undefined
);

const initialState: TaskBoardState = {
  /* ... */
};

export function TaskBoardProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(taskBoardReducer, initialState);

  // Memoize the context value to prevent unnecessary re-renders
  const value = useMemo(
    () => ({ state, dispatch }),
    [state] // Only recreate when state changes
  );

  return (
    <TaskBoardContext.Provider value={value}>
      {children}
    </TaskBoardContext.Provider>
  );
}

// Base hook
export function useTaskBoard() {
  const context = useContext(TaskBoardContext);
  if (context === undefined) {
    throw new Error('useTaskBoard must be used within a TaskBoardProvider');
  }
  return context;
}

// Selector hooks - only re-render when selected data changes
export function useTaskBoardTasks() {
  const { state } = useTaskBoard();
  return state.tasks;
}

export function useTaskBoardFilters() {
  const { state } = useTaskBoard();
  return state.filters;
}

export function useTaskBoardDispatch() {
  const { dispatch } = useTaskBoard();
  return dispatch;
}

export function useTaskBoardFormState() {
  const { state } = useTaskBoard();
  return {
    isFormOpen: state.isFormOpen,
    editingTask: state.editingTask,
  };
}

Now components can use specific selectors:

// src/components/TaskStats.tsx - Using selector
import { useTaskBoardTasks } from '../contexts/TaskBoardContext';

export function TaskStats() {
  console.log('[TaskStats] Rendering');
  const tasks = useTaskBoardTasks(); // Only subscribes to tasks

  const totalTasks = tasks.length;
  const completedTasks = tasks.filter((t) => t.status === 'done').length;
  // ... rest of component
}

Browser Console (after changing filter):

// User changes filter from "All" to "In Progress"
[FilterBar] Rendering
[TaskBoard] Rendering
[TaskList] Rendering
[TaskItem] Rendering (1 time)
// TaskStats does NOT render! βœ“

But this is still not perfect. TaskStats will still re-render when tasks change, even though it's on a hidden page. The real solution is to not use Context for everything.

When to Use Context

Use Context when:

βœ… Truly global state: Theme, authentication, language βœ… Infrequently changing: User preferences, feature flags βœ… Widely consumed: Many components need the same data βœ… Not performance-critical: Re-renders are acceptable

Don't use Context when:

❌ Frequently changing state: Form inputs, filters, search queries ❌ Performance-critical: Large lists, real-time updates ❌ Only a few components need it: Props are simpler ❌ State is local to a feature: Keep it in the feature component

Common Failure Modes with Context

Symptom: Entire app re-renders on every state change

Browser Console:

[Every component in the app] Rendering
[Every component in the app] Rendering
[Every component in the app] Rendering

React DevTools Evidence: - Profiler shows all components highlighted - Reason: Context value changes - All consumers re-render

Root cause: Context value is too broad, changes too frequently

Solution: Split into multiple contexts or use a state management library

Symptom: "useContext must be used within Provider" error

Browser Console:

Error: useTaskBoard must be used within a TaskBoardProvider
  at useTaskBoard (TaskBoardContext.tsx:45)
  at TaskItem (TaskItem.tsx:12)

Root cause: Component using context is rendered outside the Provider

Solution: Ensure Provider wraps all components that need the context

// Wrong:
<TaskItem /> {/* Outside provider */}
<TaskBoardProvider>
  <TaskBoard />
</TaskBoardProvider>

// Right:
<TaskBoardProvider>
  <TaskItem /> {/* Inside provider */}
  <TaskBoard />
</TaskBoardProvider>

Symptom: Context value is undefined or stale

React DevTools Evidence: - Context value shows old data - Component doesn't re-render when context changes

Root cause: Context value not memoized, or component not consuming context correctly

Solution: Memoize context value and ensure useContext is called at component top level

The Context Decision Framework

Question 1: How many components need this state? - 1-2 components: Use props - 3-5 components: Consider lifting state up - 6+ components: Consider Context

Question 2: How often does this state change? - Rarely (theme, auth): Context is fine - Occasionally (user settings): Context is okay - Frequently (form inputs, filters): Avoid Context

Question 3: How performance-critical is this? - Not critical (UI preferences): Context is fine - Somewhat critical (data tables): Be careful with Context - Very critical (real-time updates): Avoid Context

Question 4: Is this truly global? - Yes (current user, theme): Context is appropriate - No (feature-specific state): Keep it local

When to Apply This Solution

What Context optimizes for: - Eliminating prop drilling - Sharing state across distant components - Simplifying component interfaces - Reducing coupling between components

What Context sacrifices: - Performance (all consumers re-render) - Explicit data flow (harder to trace where data comes from) - Testing complexity (need to wrap components in Provider)

When to choose Context: - State is truly global (theme, auth, language) - Many components need the same data - Prop drilling is becoming painful - Performance is not critical

When to avoid Context: - State changes frequently - Only a few components need it - Performance is critical - State is feature-specific

Code characteristics: - Setup: Moderate (Provider, custom hooks) - Maintenance: Easy (add consumers without changing intermediate components) - Performance: Can be problematic (all consumers re-render) - Testing: More complex (need Provider wrapper)

The key insight: Context is a tool for convenience, not performance. Use it to eliminate prop drilling, but be aware of the re-render cost. For high-performance state management, you'll need a different solution (which we'll explore in the next chapter).

Lifting state up vs. pushing it down

Lifting state up vs. pushing it down

Phase 4: The State Placement Problem

We've learned three tools for managing state: 1. useState for simple, local state 2. useReducer for complex state logic 3. Context for sharing state across components

But we haven't addressed the most fundamental question: Where should state live?

This is the state placement problem, and getting it wrong causes more bugs and performance issues than any other React mistake.

The Two Competing Forces

Lifting state up: Moving state to a common ancestor so multiple components can access it

Pushing state down: Keeping state as close as possible to where it's used

These forces are in constant tension. Lift too high, and you get unnecessary re-renders. Push too low, and you get prop drilling or duplicated state.

The Failure: State in the Wrong Place

Let's add a new feature to our TaskBoard: inline task editing. Users should be able to click a task title and edit it directly, without opening the modal form.

Here's the naive approachβ€”putting edit state in the parent:

// src/components/TaskBoard.tsx - Edit state in parent (WRONG)
import { useState, useMemo } from 'react';
import { useTaskBoard } from '../contexts/TaskBoardContext';
import { TaskList } from './TaskList';

export function TaskBoard() {
  const { state, dispatch } = useTaskBoard();

  // Edit state for ALL tasks in the parent
  const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
  const [editingTitle, setEditingTitle] = useState('');

  const filteredTasks = useMemo(() => {
    return state.tasks.filter((task) => {
      // ... filtering logic
    });
  }, [state.tasks, state.filters]);

  const startEditing = (taskId: string, currentTitle: string) => {
    setEditingTaskId(taskId);
    setEditingTitle(currentTitle);
  };

  const saveEdit = () => {
    if (editingTaskId) {
      dispatch({
        type: 'UPDATE_TASK',
        payload: {
          id: editingTaskId,
          updates: { title: editingTitle },
        },
      });
      setEditingTaskId(null);
      setEditingTitle('');
    }
  };

  const cancelEdit = () => {
    setEditingTaskId(null);
    setEditingTitle('');
  };

  return (
    <div className="task-board">
      {/* ... header ... */}

      <TaskList
        tasks={filteredTasks}
        editingTaskId={editingTaskId}
        editingTitle={editingTitle}
        onStartEditing={startEditing}
        onSaveEdit={saveEdit}
        onCancelEdit={cancelEdit}
        onTitleChange={setEditingTitle}
      />
    </div>
  );
}
// src/components/TaskList.tsx - Passing edit props down
import { Task } from '../types/task';
import { TaskItem } from './TaskItem';

interface TaskListProps {
  tasks: Task[];
  editingTaskId: string | null;
  editingTitle: string;
  onStartEditing: (taskId: string, currentTitle: string) => void;
  onSaveEdit: () => void;
  onCancelEdit: () => void;
  onTitleChange: (title: string) => void;
}

export function TaskList({
  tasks,
  editingTaskId,
  editingTitle,
  onStartEditing,
  onSaveEdit,
  onCancelEdit,
  onTitleChange,
}: TaskListProps) {
  return (
    <div className="task-list">
      {tasks.map((task) => (
        <TaskItem
          key={task.id}
          task={task}
          isEditing={editingTaskId === task.id}
          editingTitle={editingTitle}
          onStartEditing={onStartEditing}
          onSaveEdit={onSaveEdit}
          onCancelEdit={onCancelEdit}
          onTitleChange={onTitleChange}
        />
      ))}
    </div>
  );
}
// src/components/TaskItem.tsx - Using edit props
import { Task } from '../types/task';
import { useTaskBoard } from '../contexts/TaskBoardContext';

interface TaskItemProps {
  task: Task;
  isEditing: boolean;
  editingTitle: string;
  onStartEditing: (taskId: string, currentTitle: string) => void;
  onSaveEdit: () => void;
  onCancelEdit: () => void;
  onTitleChange: (title: string) => void;
}

export function TaskItem({
  task,
  isEditing,
  editingTitle,
  onStartEditing,
  onSaveEdit,
  onCancelEdit,
  onTitleChange,
}: TaskItemProps) {
  const { dispatch } = useTaskBoard();

  return (
    <div className="task-item">
      <div className="task-header">
        {isEditing ? (
          <input
            type="text"
            value={editingTitle}
            onChange={(e) => onTitleChange(e.target.value)}
            onBlur={onSaveEdit}
            onKeyDown={(e) => {
              if (e.key === 'Enter') onSaveEdit();
              if (e.key === 'Escape') onCancelEdit();
            }}
            autoFocus
          />
        ) : (
          <h3 onClick={() => onStartEditing(task.id, task.title)}>
            {task.title}
          </h3>
        )}
      </div>
      {/* ... rest of task item ... */}
    </div>
  );
}

Let's test this:

Browser Behavior: 1. User clicks on task title "Implement user authentication" 2. Input appears, user types "Implement OAuth authentication" 3. User clicks on a different task title "Write API documentation" 4. Both tasks show edit inputs! ❌ 5. The second task's input shows "Implement OAuth authentication" ❌

Browser Console:

[TaskBoard] Rendered
[TaskList] Rendered
[TaskItem] Rendered (3 times)

// User clicks first task:
[TaskBoard] Re-rendered (editingTaskId changed)
[TaskList] Re-rendered (props changed)
[TaskItem] Re-rendered (3 times - ALL tasks re-render!)

// User types in input:
[TaskBoard] Re-rendered (editingTitle changed)
[TaskList] Re-rendered (props changed)
[TaskItem] Re-rendered (3 times - ALL tasks re-render on every keystroke!)

React DevTools Evidence: - Profiler shows all 3 TaskItem components re-rendering on every keystroke - State in TaskBoard: - editingTaskId: "1" - editingTitle: "Implement OAuth authentication" - When user clicks second task: - editingTaskId changes to "2" - But editingTitle still has the old value - Second task shows wrong text

Diagnostic Analysis: State Too High

What the user experiences: - Expected: Only the edited task updates - Actual: All tasks re-render, wrong task shows edit state

What the console reveals: - Key indicator: All tasks re-render on every keystroke - This is a performance problem - 3 tasks Γ— 10 keystrokes = 30 unnecessary re-renders

What DevTools shows: - Component tree: TaskBoard β†’ TaskList β†’ TaskItem (all highlighted) - Render reason: Props changed - The problem: Edit state is in the parent, so changing it re-renders all children

Root cause identified:

Edit state is lifted too high. Only one task is being edited at a time, but the state lives in TaskBoard, which means: 1. Changing edit state re-renders the entire board 2. All tasks re-render even though only one is editing 3. Edit state is shared across all tasks (causing the wrong-text bug)

Why the current approach can't solve this:

When state lives in a parent component, changing that state re-renders all children. React can't know that only one child cares about the edit state.

What we need:

Edit state should live in the TaskItem component itself. Each task should manage its own edit state independently.

Iteration 4: Pushing State Down

Let's move edit state to where it's actually used:

// src/components/TaskBoard.tsx - No edit state here
import { useMemo } from 'react';
import { useTaskBoard } from '../contexts/TaskBoardContext';
import { TaskList } from './TaskList';

export function TaskBoard() {
  const { state, dispatch } = useTaskBoard();

  const filteredTasks = useMemo(() => {
    return state.tasks.filter((task) => {
      // ... filtering logic
    });
  }, [state.tasks, state.filters]);

  return (
    <div className="task-board">
      {/* ... header ... */}

      <TaskList tasks={filteredTasks} />
      {/* No edit props! */}
    </div>
  );
}
// src/components/TaskList.tsx - No edit props here either
import { Task } from '../types/task';
import { TaskItem } from './TaskItem';

interface TaskListProps {
  tasks: Task[];
}

export function TaskList({ tasks }: TaskListProps) {
  return (
    <div className="task-list">
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
        {/* No edit props! */}
      ))}
    </div>
  );
}
// src/components/TaskItem.tsx - Edit state lives here
import { useState } from 'react';
import { Task, TaskStatus } from '../types/task';
import { useTaskBoard } from '../contexts/TaskBoardContext';

interface TaskItemProps {
  task: Task;
}

export function TaskItem({ task }: TaskItemProps) {
  const { dispatch } = useTaskBoard();

  // Edit state is local to this component
  const [isEditing, setIsEditing] = useState(false);
  const [editingTitle, setEditingTitle] = useState(task.title);

  const startEditing = () => {
    setIsEditing(true);
    setEditingTitle(task.title);
  };

  const saveEdit = () => {
    if (editingTitle.trim() !== task.title) {
      dispatch({
        type: 'UPDATE_TASK',
        payload: {
          id: task.id,
          updates: { title: editingTitle.trim() },
        },
      });
    }
    setIsEditing(false);
  };

  const cancelEdit = () => {
    setEditingTitle(task.title);
    setIsEditing(false);
  };

  const priorityColors = {
    low: '#4caf50',
    medium: '#ff9800',
    high: '#f44336',
  };

  return (
    <div className="task-item">
      <div className="task-header">
        {isEditing ? (
          <input
            type="text"
            value={editingTitle}
            onChange={(e) => setEditingTitle(e.target.value)}
            onBlur={saveEdit}
            onKeyDown={(e) => {
              if (e.key === 'Enter') saveEdit();
              if (e.key === 'Escape') cancelEdit();
            }}
            autoFocus
            className="task-title-input"
          />
        ) : (
          <h3 onClick={startEditing} className="task-title">
            {task.title}
          </h3>
        )}
        <span
          className="priority-badge"
          style={{ backgroundColor: priorityColors[task.priority] }}
        >
          {task.priority}
        </span>
      </div>

      <p className="task-description">{task.description}</p>

      <div className="task-meta">
        <span>Assignee: {task.assignee}</span>
        {task.dueDate && (
          <span>Due: {task.dueDate.toLocaleDateString()}</span>
        )}
      </div>

      <div className="task-actions">
        <select
          value={task.status}
          onChange={(e) =>
            dispatch({
              type: 'UPDATE_TASK_STATUS',
              payload: { id: task.id, status: e.target.value as TaskStatus },
            })
          }
        >
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="done">Done</option>
        </select>

        <button
          onClick={() =>
            dispatch({ type: 'OPEN_EDIT_FORM', payload: task })
          }
        >
          Edit
        </button>
        <button
          onClick={() =>
            dispatch({ type: 'DELETE_TASK', payload: task.id })
          }
        >
          Delete
        </button>
      </div>
    </div>
  );
}

Verification: Does It Work?

Browser Console:

[TaskBoard] Rendered
[TaskList] Rendered
[TaskItem] Rendered (3 times)

// User clicks first task:
[TaskItem] Re-rendered (1 time - only the edited task!)

// User types in input:
[TaskItem] Re-rendered (1 time - only the edited task!)
[TaskItem] Re-rendered (1 time - only the edited task!)
[TaskItem] Re-rendered (1 time - only the edited task!)

// User clicks second task:
[TaskItem] Re-rendered (1 time - first task saves and stops editing)
[TaskItem] Re-rendered (1 time - second task starts editing)

React DevTools Evidence: - Profiler shows only the edited TaskItem re-rendering - Other tasks remain unchanged - Each task has its own independent edit state - No interference between tasks

Expected vs. Actual: - βœ… Only edited task re-renders - βœ… Other tasks unaffected - βœ… Each task has independent edit state - βœ… No wrong-text bug - βœ… Performance: 3 re-renders instead of 30

The State Placement Decision Tree

Use this flowchart to decide where state should live:

Question 1: Does only one component need this state? - Yes β†’ Keep it in that component - No β†’ Continue to Question 2

Question 2: Do sibling components need to share this state? - Yes β†’ Lift to common parent - No β†’ Continue to Question 3

Question 3: Do components far apart in the tree need this state? - Yes β†’ Consider Context - No β†’ Keep it local

Question 4: Does this state change frequently? - Yes β†’ Keep it as low as possible - No β†’ Lifting is okay

Question 5: Is this state truly global? - Yes β†’ Use Context - No β†’ Keep it local or lift minimally

The Lifting State Up Pattern

Sometimes you do need to lift state. Here's when and how:

When to lift state up: - Two sibling components need to share state - Parent needs to coordinate children - State represents a single source of truth

Example: Coordinated filters

// src/components/TaskBoard.tsx - Filters must be lifted
import { useState } from 'react';
import { TaskFilters } from '../types/task';
import { FilterBar } from './FilterBar';
import { TaskList } from './TaskList';
import { TaskStats } from './TaskStats';

export function TaskBoard() {
  // Filters must be in parent because both FilterBar and TaskList need them
  const [filters, setFilters] = useState<TaskFilters>({
    status: 'all',
    priority: 'all',
    assignee: '',
    searchQuery: '',
  });

  const filteredTasks = tasks.filter((task) => {
    // Apply filters...
  });

  return (
    <div className="task-board">
      {/* FilterBar modifies filters */}
      <FilterBar filters={filters} onFilterChange={setFilters} />

      {/* TaskList uses filtered results */}
      <TaskList tasks={filteredTasks} />

      {/* TaskStats uses filtered results */}
      <TaskStats tasks={filteredTasks} />
    </div>
  );
}

This is correct lifting: filters are in the parent because multiple children need them.

The Pushing State Down Pattern

When to push state down: - State is only used by one component - State changes frequently - State is UI-specific (not business logic)

Example: Dropdown open state

// src/components/TaskItem.tsx - Dropdown state is local
import { useState } from 'react';
import { Task } from '../types/task';

export function TaskItem({ task }: { task: Task }) {
  // Dropdown state is local - only this component cares
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);

  return (
    <div className="task-item">
      <h3>{task.title}</h3>

      <button onClick={() => setIsDropdownOpen(!isDropdownOpen)}>
        Actions
      </button>

      {isDropdownOpen && (
        <div className="dropdown">
          <button onClick={() => {/* edit */}}>Edit</button>
          <button onClick={() => {/* delete */}}>Delete</button>
        </div>
      )}
    </div>
  );
}

This is correct pushing: dropdown state is local because only this component cares about it.

Common Failure Modes

Symptom: Entire component tree re-renders on every keystroke

Browser Console:

[Parent] Rendering
[Child1] Rendering
[Child2] Rendering
[Child3] Rendering
// ... repeats on every keystroke

Root cause: Input state is lifted too high

Solution: Push input state down to the component that contains the input

Symptom: Components can't share state

Browser Console:

Warning: Cannot update a component while rendering a different component

Root cause: State is too low, siblings can't communicate

Solution: Lift state to common parent

Symptom: Props drilling through many levels

React DevTools Evidence: - Component tree shows props passed through 5+ levels - Intermediate components don't use the props

Root cause: State is lifted too high, or should use Context

Solution: Either push state down or use Context

The Performance Impact

Let's measure the difference:

State Too High (edit state in parent):

User types 10 characters
= 10 state updates in parent
= 10 re-renders of parent
= 10 re-renders of TaskList
= 10 Γ— 3 re-renders of TaskItem
= 30 total TaskItem re-renders

State Pushed Down (edit state in TaskItem):

User types 10 characters
= 10 state updates in TaskItem
= 10 re-renders of TaskItem
= 10 total TaskItem re-renders

Result: 3Γ— fewer re-renders by pushing state down.

The Complete Journey: State Placement Evolution

Iteration State Location Problem Solution Performance
0 Edit state in TaskBoard All tasks re-render - 30 re-renders
1 Edit state in TaskItem Only edited task re-renders Push state down 10 re-renders
2 Filters in TaskBoard Multiple components need them Lift state up Correct
3 Dropdown in TaskItem Only one component needs it Keep state local Optimal

Decision Framework: Where Should State Live?

Principle 1: Start Local

Always start with state in the component that uses it. Only lift when you have a concrete reason.

Principle 2: Lift Minimally

When you do lift, lift only as high as necessary. Don't lift to App if lifting to a feature component is enough.

Principle 3: Consider Performance

Frequently changing state should be as low as possible. Infrequently changing state can be higher.

Principle 4: Optimize for Maintainability

Sometimes it's worth a small performance cost for clearer code. Don't prematurely optimize.

When to Apply These Patterns

Push State Down: - What it optimizes for: Performance, isolation, simplicity - What it sacrifices: Sharing state between components - When to use: State is local, changes frequently, UI-specific - When to avoid: Multiple components need the state

Lift State Up: - What it optimizes for: Sharing state, coordination, single source of truth - What it sacrifices: Performance (more re-renders), complexity - When to use: Siblings need to share state, parent needs to coordinate - When to avoid: Only one component needs the state

Use Context: - What it optimizes for: Eliminating prop drilling, global state - What it sacrifices: Performance (all consumers re-render), explicit data flow - When to use: Truly global state, many distant components need it - When to avoid: State changes frequently, only a few components need it

Lessons Learned

1. State placement is a design decision

There's no one right answer. Consider: - How many components need the state? - How often does it change? - How performance-critical is it? - How maintainable is the code?

2. Start simple, refactor when needed

Don't prematurely optimize. Start with the simplest solution (local state), and refactor when you encounter problems.

3. Performance is about re-renders

The higher state lives, the more components re-render when it changes. This is the fundamental trade-off.

4. Composition over configuration

Sometimes the best solution is to restructure your components, not to move state around.

5. Measure, don't guess

Use React DevTools Profiler to see actual re-render counts. Don't optimize based on assumptions.

The key insight: State placement is the most important decision you make in React. Get it right, and your app is fast and maintainable. Get it wrong, and you'll fight performance problems and prop drilling forever.